feat(#298): artifact upload endpoint + pluggable storage backend - #319
Conversation
- POST /api/v1/external-results/artifact — multipart upload endpoint - Fields: case_result_id, kind, filename, file (locked contract) - Content-type derived from file part header - Allowlist enforcement (bypass for kind=other) - Streaming size enforcement (64 KiB chunks, 413 + cleanup on overflow) - Audit log with all 5 required fields - StorageBackend ABC + LocalFsBackend + S3Backend stub (raises clearly) - get_storage() function (no module-level singleton) - external_case_artifacts model with artifact_kind enum (create_type=False) - Alembic migration l1m2n3o4p5q6 with PG enum create/drop (checkfirst=True) - Dev-only static route for local artifacts (gated on BGSTM_STORAGE_BACKEND=local) - python-multipart==0.0.27 added to requirements.txt - 10 new tests covering happy path, 413+cleanup+partial-write, 415, 422, 404, 401, S3 stub - Spec doc updated with multipart contract, storage abstraction, and audit-log shape Agent-Logs-Url: https://github.com/bg-playground/BGSTM/sessions/bb8b1cee-6871-442b-b48a-b77d07fa2607 Co-authored-by: bg-playground <259109604+bg-playground@users.noreply.github.com>
Agent-Logs-Url: https://github.com/bg-playground/BGSTM/sessions/bb8b1cee-6871-442b-b48a-b77d07fa2607 Co-authored-by: bg-playground <259109604+bg-playground@users.noreply.github.com>
|
Reviewed end-to-end. The architecture is clean — Two issues to address before merge, plus a small cosmetic. 🛑 1. Path traversal via user-controlled
|
…w #4408185626)
- Path traversal (issue 1): reject filenames with path separators or chars
outside [A-Za-z0-9._-]{1,255} with 422; second-line defense in
LocalFsBackend.save() via resolve() + is_relative_to() check; 8 new
parametrized path-traversal tests + LocalFsBackend direct-key test
- Streaming scope (issue 2, option A): reword 413 test docstring and spec
doc §7 to accurately describe Starlette buffering; recommend reverse-proxy
client_max_body_size for first-line DoS protection
- Index name alignment (issue 3): replace index=True on column with explicit
__table_args__ Index("idx_external_case_artifacts_case_result_id", ...)
matching the migration name
Agent-Logs-Url: https://github.com/bg-playground/BGSTM/sessions/abee1c5c-cf1f-45a2-86d5-8a8c18f06506
Co-authored-by: bg-playground <259109604+bg-playground@users.noreply.github.com>
- _SAFE_FILENAME_RE now requires first char to be alphanumeric, blocking ".", "..", "..." and similar dot-only names from passing the allowlist - Add ".", "..", "..." parametrized test cases to TestFilenameValidation - Add comment to __table_args__ Index explaining the explicit name matches the migration to prevent alembic autogenerate noise Agent-Logs-Url: https://github.com/bg-playground/BGSTM/sessions/abee1c5c-cf1f-45a2-86d5-8a8c18f06506 Co-authored-by: bg-playground <259109604+bg-playground@users.noreply.github.com>
All three issues addressed in commits Issue 1 — Path traversal:
Issue 2 — Streaming scope: Went with option A. The 413 test docstring and spec doc §7 now accurately describe the Starlette post-buffer behavior and recommend a reverse-proxy Issue 3 — Index name: |
|
LGTM ✅ — all three review items resolved cleanly.
The Two optional, non-blocking nits for whenever (don't hold up merge):
Separately, I noticed §d still documents "Artifacts — deduplication by SHA-256" but the implementation here doesn't hash or dedup. Pre-existing spec drift, not this PR's job — happy to file a follow-up issue to either implement or remove from the spec. Approving. 🚀 |
Implements
POST /api/v1/external-results/artifact(multipart) with a pluggableStorageBackendabstraction, post-buffer size enforcement, filename sanitization, and an audit log shape load-bearing to the smoke workflow in PR #314.Endpoint
Multipart field names are a locked contract (reporter pinned at SHA
ab5d7c1):case_result_idexternal_case_results.idkindscreenshot|video|trace|log|otherfilenamefileContent-Typeheader of this part is the artifact MIME typecontent_typeandsize_bytesare derived, not sent as separate fields.Filename sanitization (path-traversal defense)
filenameis validated with two layers of defense:422if it differs from its ownos.path.basename()(catches../,subdir/,/etc/) or fails the allowlist regex^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$(blocks null bytes, backslashes, dot-only names like.and.., names exceeding 255 chars, etc.).LocalFsBackendlayer —save()calls.resolve()+is_relative_to()as a secondary check; raisesValueErrorif the resolved path escapes the artifact root.Storage abstraction (
backend/app/storage/)StorageBackendABC —save(stream, *, key, content_type) → StorageResultandurl_for(key) → strLocalFsBackend— writes underBGSTM_ARTIFACTS_DIR; URLs viaBGSTM_ARTIFACT_URL_PREFIXS3Backend— stub; raisesNotImplementedError("S3 backend not yet implemented; set BGSTM_STORAGE_BACKEND=local")get_storage()is a function, not a module-level singleton — tests swapsettingswithout import-time side effectsSize enforcement
The server enforces
BGSTM_ARTIFACT_MAX_BYTES(default 50 MiB) by reading the upload in 64 KiB chunks and accumulating a byte count, returning413and cleaning up the temp file when the limit is exceeded. Note: FastAPI/Starlette fully parses and spools the multipart body before the handler runs, so this check operates on the spooled copy rather than the live network stream. For first-line DoS protection, configure your reverse proxy (e.g. nginxclient_max_body_size) to reject oversized bodies before they reach the application. True in-stream early-abort is a follow-up improvement.Database
New
external_case_artifactstable withON DELETE CASCADEFK toexternal_case_results.id. Migrationl1m2n3o4p5q6follows the lesson from #317: usespostgresql.ENUM(..., create_type=False)+.create(bind, checkfirst=True)inupgrade()and.drop(bind, checkfirst=True)indowngrade(). Model-sideEnumcarries explicitname="artifact_kind"andcreate_type=False. Index name oncase_result_idis explicit (idx_external_case_artifacts_case_result_id) and matches the migration to preventalembic --autogeneratenoise.Audit log
Every successful upload emits
external_results.artifact.uploadwith exactly these five fields (required byassert.pyin PR #314):{ "case_result_id": "<uuid>", "kind": "screenshot", "size_bytes": 20480, "filename": "failure-state.png", "content_type": "image/png" }Other
StaticFilesroute at/artifactsmounted only whenBGSTM_STORAGE_BACKEND=localpython-multipart==0.0.27added torequirements.txt(patched against two prior CVEs)external_results_v1.md) updated: §7 rewritten for the actual multipart contract and accurate size-enforcement description; new §g documents the storage abstraction and config surfaceOriginal prompt
Goal
Implement issue #298 — artifacts upload endpoint + pluggable storage backend for the external-results API. This builds on #317 (external case results), which is now merged on
main.Reference issues:
external_case_resultstable is now available onmain)bgstm:requirementannotations and BGSTM should resolve external→UUID for case-result linking #316 (do not reference [v0.2] BGSTMReporter should transmitbgstm:requirementannotations and BGSTM should resolve external→UUID for case-result linking #318 — that was closed as a duplicate)Scope
New endpoint
POST /api/v1/external-results/artifact— multipart upload.bgstm-playwright-frameworkspinned SHAab5d7c1already sends these):case_result_id(string, UUID)kind(string, one of theartifact_kindenum values — at minimum:screenshot,video,trace,log,other)filename(string)file(the binary file part){ id, url, kind, size_bytes, content_type, filename, case_result_id }.Storage abstraction
StorageBackendABC with at least:save(stream, *, key, content_type) -> StorageResultandurl_for(key) -> str.LocalFsBackendimplementation — writes under a configured root (e.g.BGSTM_ARTIFACTS_DIR), returns a URL served by a static dev-only route.S3Backendstub class that raisesNotImplementedErrorwith a clear message ("S3 backend not yet implemented; set BGSTM_STORAGE_BACKEND=local").get_storage()is a function, not a module-level singleton — tests must be able to swap settings cleanly without import-time side effects.BGSTM_STORAGE_BACKEND=local|s3).Streaming size + content-type enforcement
BGSTM_ARTIFACT_MAX_BYTES) while streaming — do not read the full body into memory.413 Payload Too Largeand ensure the partial file is cleaned up.content_typeagainst an allowlist (or a sensible default allowlist perkind).Database
external_case_artifactstable with FK toexternal_case_results.id(cascade delete).id(UUID PK),case_result_id(UUID FK),kind(enumartifact_kind),filename,content_type,size_bytes,storage_key,url,created_at.artifact_kindPostgres enum.Alembic migration — critical (lesson from #317 round 1)
postgresql.ENUM(..., name="artifact_kind", create_type=False)and call.create(bind, checkfirst=True)manually inupgrade()from the first commit. Do not rely on SQLAlchemy auto-creating the type.downgrade()with.drop(bind, checkfirst=True)after the table drop.Enum(...)MUST have explicitname="artifact_kind"andcreate_type=False.Static route (dev only)
BGSTM_STORAGE_BACKEND=local.Audit log
detailsJSON must include all five of these fields (smoke'sassert.pyin PR Add BGSTM external-results smoke workflow pinned to reporter SHA ab5d7c1 with main-branch, audit-log, and artifact-path compatibility hardening #314 reconstructs from these — every one is required):case_result_idkindsize_bytesfilenamecontent_typeSpec doc
docs/specs/external_results_v1.md(or the equivalent onmain) to document the new endpoint, the multipart contract, the storage abstraction, the size/content-type enforcement, and the audit-log shape.Out of scope
bgstm:requirementannotation handling andrequirement_external_idsresolution — tracked in [v0.2] BGSTMReporter should transmitbgstm:requirementannotations and BGSTM should resolve external→UUID for case-result linking #316.Acceptance criteria
POST /api/v1/external-results/artifactaccepts multipart with field names exactlycase_result_id,kind,filename,file.StorageBackendABC +LocalFsBackendimpl +S3Backendstub that raises a clearNotImplementedError.get_storage()is a function (no module-level singleton); tests can swap settings cleanly.external_case_artifactstable with FK toexternal_case_results.id.create_type=False+ manual.create(checkfirst=True)from the first commit; same in downgrade with.drop(checkfirst=True).Enum(...)has explicitname="artifact_kind"andcreate_type=False.detailsincludes all ...This pull request was created from Copilot chat.